iOS事件 - 响应者链和事件分发

响应者对象就是可以响应事件并对事件作出处理。在 iOS 中,存在 UIResponder 类,它定义了响应者对象的所有方法。UIApplicationUIView 等类都继承了 UIResponder 类,UIWindowUIKit 中的控件因为继承了 UIView,所以也间接继承了 UIResponder 类,这些类的实例都可以当作响应者。

当前接受触摸的响应者对象被称为第一响应者,即表示当前该对象正在与用户交互,它是响应者链的开端。

响应者链表示一系列的响应者对象。事件被交由第一响应者对象处理,如果第一响应者不处理,事件被沿着响应者链向上传递,交给下一个响应者。

一般来说,第一响应者是个视图对象或者其子类对象,当其被触摸后事件被交由它处理,如果它不处理,事件就会被传递给它的视图控制器对象(如果存在),然后是它的父视图对象(如果存在),以此类推,直到顶层视图。

接下来会沿着顶层视图(top view)到窗口(UIWindow)再到程序(UIApplication)。如果整个过程都没有响应这个事件,该事件就被丢弃。

一般情况下,在响应者链中只要由对象处理事件,事件就停止传递。但有时候可以在视图的响应方法中根据一些条件判断来决定是否需要继续传递事件。

事件分发

视图对触摸事件是否需要作处回应可以通过设置视图的 userInteractionEnabled 属性。默认状态为 YES,如果设置为 NO,可以阻止视图接收和分发触摸事件。除此之外,当视图被隐藏或者透明也不会接收事件。

不过这个属性只对视图有效,如果想要整个程序都不响应事件,可以调用 UIApplicationbeginIngnoringInteractionEvents 方法来完全停止事件接收和分发。通过 endIngnoringInteractionEvents 方法来恢复让程序接收和分发事件。

如果要让视图接收多点触摸,需要设置它的 multipleTouchEnabled 属性为 YES,默认状态下这个属性值为NO,即视图默认不接收多点触摸,整个 iOS 触摸事件从产生到寂灭大致如下图:

img

起始阶段

  1. CPU 处于睡眠状态,等待事件发生;
  2. 手指触摸屏幕

系统响应阶段

  1. 屏幕硬件感应到输入,并将感应到的事件传递给输入输出驱动 IOKit
  2. IOKit.framework 封装整个触摸事件为 IOHIDEvent 对象;
  3. IOKit.framework 通过 IPC 将事件转发给 SpringBoard.app

SpringBoard.app 就是 iOS 的系统桌面,当触摸事件发生时,也只有负责管理桌面的 SpringBoard.app 才知道如何正确的响应。因为触摸发生时,有可能用户正在桌面翻页找 App,也有可能正处于在微信中刷朋友圈。

以上是系统层的响应,系统感应到外界的输入,并将相应的输入封装成比较概括的 IOHIDEvent 对象,然后 UIKit 通过 IOHIDEvent 的类型,判断出相应事件应该由 SpringBoard .app 处理,直接通过 Mach Port(IPC进程间通信) 转发给 SpringBoard.app

桌面响应阶段

SpringBoard.app 主线程 Runloop 收到 IOKit.framework 转发来的消息苏醒,并触发对应 Mach PortSource1 回调 __IOHIDEventSystemClientQueueCallback()

如果 SpringBoard.app 监测到有 App 在前台,即 xxxx.app,SpringBoard.app 通过 Mach Port(IPC进程间通信) 转发给 xxxx.app,如果 SpringBoard.app 监测到监测无前台 App,则 SpringBoard.app 进入 App 内部响应阶段的第二段,即触发 Source0 回调。

Source1 事件响应

苹果注册了一个 Source1 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()

当一个硬件事件,如触摸/锁屏/摇晃等发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,距离传感器等几种 Event,随后用 Mach Port 转发给需要的 App 进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture,处理屏幕旋转,发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

Source0 手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个 Observer 的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行 GestureRecognizer 的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

App内部响应阶段

  1. 前台 App 主线程 Runloop 收到 SpringBoard.app 转发来的消息苏醒,并触发对应 Mach PortSource1 回调 __IOHIDEventSystemClientQueueCallback()
  2. Source1 回调内部触发 Source0 回调 __UIApplicationHandleEventQueue()
  3. Soucre0 回调内部,封装 IOHIDEventUIEvent
  4. 平时开发熟悉的触摸事件响应链从这开始了;
  5. 通过递归调用UIView层级的 hitTest(_:with:) ,结合 point(inside:with:) 找到 UIEvent 中每一个 UITouch 所属的 UIView,其实是想找到离触摸事件点最近的那个UIView
  6. 这个过程是从 UIView 层级的最顶层往最底层递归查询,但这不是 UIResponder 响应链,事件响应是在 UIEvent 中每一个 UITouch 所属的 UIView 都确定之后方才开始。

但需要注意,以下三种情况 UIViewhitTest(_:with:) 不会被调用,也导致其子 UIViewhitTest(_:with:) 不会被调用,而之后响应事件是下向上传递的,这直接导致以下三种情况的 UIView 及其子 UIView 不接收任何触摸事件:

  1. userInteractionEnabled = NO
  2. hidden = YES
  3. alpha = 0.0~0.01之间

UIImageView 的 userInteractionEnabled 默认为NO,因此 UIImageView 以及它的子控件默认是不接收触摸事件的。

当把断点打在某个 UIView hitTest(_:with:) 中时,对应的调用堆栈如下:
img

  1. 根据围绕 UITouch 所属的 UIView 及其父视图 UIViewUIGestureRecognizer,来确定一个 UITouchUIGestureRecognizer

  2. UITouch 所属的 UIViewgestureRecognizers 收到此 UITouch 和相应的 UIEvent,并按照 UITouch 所处的状态调用四大 UITouch 方法中的一个,事件响应开始;

  3. 对于 UIView 收到的 UITouch 事件,四大 UITouch 事件都是如此,则会按照 UIResponder 响应链一直往上传递,直到某个 UIResponder 因为主动响应触摸事件,切断了响应链,即不调用下一个 UIResponder 的响应方法,如果一直没有 UIResponder 做响应处理,则这些 UITouch 到达最后的响应者即 UIApplication 后,就被吃掉消失。

  4. 如果在事件响应过程中,有 UIGestureRecognizer 成功识别,则此 UIGestureRecognizer 将独自占有所需要的 UITouch,这些 UITouch 所属的 UIView 及其他的 UIGestureRecognizertouchesCancelled(_:with:) 方法将调用。

    如果在手势的代理中设置可以同时识别两个手势,则允许同时识别的手势均可以收到所需要的 UITouch事件,但与识别成功的 UIGestureRecognizer 无关的 UITouch 则会继续按照上述传递逻辑传递。也即允许两个手势同时识别,只要所占有的 UITouch 不相同。

  5. 如果 UIGestureRecognizer 识别成功,则调用相应的 action,处理对应的逻辑。如果某个 UIResponder 主动响应了触摸事件,则根据其本身的响应逻辑处理对应的业务,UIControl 都是主动响应并切断 UITouch 的向上传递的。

  6. UITouch 事件流动完毕,整个系统重新进入睡眠等待下一个事件。

响应者链

通常,一个 iOS 应用中,在一块屏幕上通常有很多的 UI 控件,也就是有很多的 View,那么当一个事件发生时,如何来确定是哪个 View 响应了这个事件呢,接下来我们就一起来看看。

寻找hit-test view

什么是 hit-test view 呢?简单来说就是你触发事件所在的那个 View,寻找 hit-test view 的过程就叫做 Hit-Testing

发生触摸事件后,系统会将该事件加入到一个由 UIApplication 管理的事件队列中,为什么是队列而不是栈呢?因为队列是先进先出,触摸的处理也是顺序执行的。

UIApplication 会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常,先发送事件给应用程序的主窗口 keyWindow,主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件,这也是整个事件处理过程的第一步。找到合适的视图控件后,就会调用视图控件的 touches 方法来作具体的事件处理:

1
2
3
touchesBegan…
touchesMoved…
touchedEnded…

那么响应链是如何找到最合适的控件来处理事件的呢?

  1. 自己是否能接收触摸事件;
  2. 触摸点是否在自己身上;
  3. 从后往前遍历子控件数组,重复前面的两个步骤;
  4. 如果没有符合条件的子控件,那么就自己最适合处理;

下面是 hitTest 实现的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//调用时机:只要时间传递到此控件,就会执行 hitTest 方法
//作用: 寻找最合适的 view 给你
//UIApplication -> [UIWindow hitTest:withEvent:] 寻找最合适的view告诉系统
//point:当前手指触摸的点
//point:是方法调用者坐标系上的点
override func hitTest(_ point: CGPoint, with event: `UIEvent`?) -> `UIView`? {
//1.保证开启交互,如未开启,没有合适的控件
guard isUserInteractionEnabled == true else {
return nil
}
//2.保证未隐藏,如隐藏,没有合适的控件
guard isHidden == false else {
return nil
}
//3.保证透明度大于 0.01,如 <= 0.01,没有合适的控件
guard alpha > 0.01 else {
return nil
}
//4.保证触摸点在自己的视图内,如不在,没有合适的控件
guard self.point(inside: point, with: event) == true else {
return nil
}
//5.便利控件下的子控件,如有更合适的子控件,返回
for subView in self.subviews {
let subPoint = self.convert(point, to: subView)
if let fitView = subView.hitTest(subPoint, with: event) {
return fitView
}
}
//6. 如没有更合适的子控件,返回自身
return self
}

在查找最合适的 View 的过程中用到了两个最重要的方法

1
2
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

hitTest:withEvent:

只要事件一传递给一个控件,这个控件就会调用自己的 hitTest:withEvent: 方法,用于寻找并返回最合适的 View, 它不管这个控件能不能处理事件也不管点是否在 View 上,事件都会先传给这 View 再调用这个 View 的 hitTest:withEvent: 方法。不管点击哪里,最合适的 View 都是 hitTest 返回的那个 View。

利用这个特性可以拦截事件的处理:

事件传递给谁就会调用这个 View 的 hitTest:withEvent: 方法,如果返回 nil,那么该方法的控件本身和子控件不是最合适的 View,那么最合适的 View 就是该控件的父控件。

如果想让 A 成为最合适的 View 就重写 A 的父控件 B 的 hitTest:withEvent: 方法,或者自己的 hitTest:withEvent: 方法返回 self,建议采用第一种。

特殊情况

  • 谁都不能处理事件,窗口也不能处理。

    重写 window 的 hitTest:withEvent: 方法返回 nil

  • 只能由窗口处理事件。

    控制器的 view 的 hitTest:withEvent: 方法返回 nil

    或者 window 的 hitTest:withEvent: 方法返回 self

  • 返回 nil 的含义:

    调用当前 hitTest:withEvent: 方法 return nil 的意思是 View 不是合适的 View,子控件也不是合适的 View。如果同级的兄弟控件也没有合适的 View,那么最合适的 View 就是父控件。

pointInside:withEvent

pointInside:withEvent: 方法判断点在不在当前 View 上(方法调用者的坐标系上)如果返回 YES,代表点在方法调用者的坐标系上;

返回 NO 代表点不在方法调用者的坐标系上,那么方法调用者也就不能处理事件。我们可以重写这个方法,主动拦截事件的传递:

1
2
3
4
5
//作用:判断下传入过来的点在不在方法调用者的坐标系上
// point:是方法调用者坐标系上的点
override func point(inside point: CGPoint, with event: `UIEvent`?) -> Bool {
return false
}

响应者对象(Responsder Object)

响应者对象是能够响应并且处理事件的对象,UIResponder 是所有响应者对象的父类,包括 UIApplicationUIViewUIViewController 都是 UIResponder 的子类。也就意味着所有的 View 和 ViewController 都是响应者对象。

第一响应者(First Responder)

第一响应者是第一个接收事件的 View 对象,我们在 Xcode 的 Interface Builder 画视图时,可以看到视图结构中就有 First Responder。

img

这里的 First Responder 就是 UIApplication 了。另外,我们可以控制一个 View 让其成为 First Responder,通过实现 canBecomeFirstResponder 方法并返回 YES 可以使当前 View 成为第一响应者,或者调用 View 的 becomeFirstResponder 方法也可以,例如当 UITextField 调用该方法时会弹出键盘进行输入,此时输入框控件就是第一响应者。

事件传递机制

如上所说,如果 hit-test view 不能处理当前事件,那么事件将会沿着响应者链(Responder Chain)进行传递,直到遇到能处理该事件的响应者(Responsder Object)。通过下图,我们来看看两种不同情况下得事件传递机制。

img

左边的情况,接收事件的 initial view 如果不能处理该事件并且她不是顶层的 View,则事件会往它的父 View 进行传递。initial view 的父 View 获取事件后如果仍不能处理,则继续往上传递,循环这个过程。如果顶层的 View 还是不能处理这个事件的话,则会将事件传递给它们的 ViewController,如果 ViewController 也不能处理,则传递给 UIWindow,此时 Window 不能处理的话就将事件传递给 UIApplication,最后如果连 Application 也不能处理,则废弃该事件。

右边图的流程唯一不同就在于,如果当前的 ViewController 是有层级关系的,那么当子 ViewController 不能处理事件时,它会将事件继续往上传递,直到传递到其 Root ViewController,后面的流程就跟之前分析的一样了。

这就是事件响应者链的传递机制,通过这些内容,我们可以更深入的了解事件在 iOS 中得传递机制,对我们在实际开发中更好的理解事件操作的原理有很大的帮助,也对我们实现复杂布局进行事件处理时增添了多一份的理解。

事件传递的完整过程

  1. 先将事件对象由上往下传递(由父控件传递给子控件),找到最合适的控件来处理这个事件;
  2. 调用最合适控件的 touches… 方法;
  3. 如果调用了 super touches… 就会将事件顺着响应者链条往上传递,传递给上一个响应者;
  4. 接着就会调用上一个响应者的 touches…. 方法;

判断上一个响应者

  1. 如果当前这个 View 是控制器的 View,那么控制器就是上一个响应者;
  2. 如果当前这个 View 不是控制器的 View,那么父控件就是上一个响应者;

UIResponder

如果你观察一下 UIView 的子类,可以发现 3 个基类: reponders (响应者),views (视图)和 controls (控件)。我们快速重温一下它们之间发生了什么。

UIResponderUIView 的父类。responder 能够处理触摸、手势、远程控制等事件。之所以它是一个单独的类而没有合并到 UIView 中,是因为 UIResponder 有更多的子类,最明显的就是 UIApplicationUIViewController。通过重写 UIResponder 的方法,可以决定一个类是否可以成为第一响应者,例如当前输入焦点元素。

iOS 中要响应事件都必须继承 UIResponder,且是对象,我们称之为响应者对象。 继承 UIResponder 的有:

  • UIApplication
  • UIViewController
  • UIView

当触摸或运动传感器等交互行为发生时,它们被发送给第一响应者,通常是一个视图。如果第一响应者没有处理,则该行为沿着响应链到达视图控制器,如果行为仍然没有被处理,则继续传递给应用。如果想监测晃动手势,可以根据需要在这 3 层中的任意位置处理。

UIResponder 还允许自定义输入方法,从 inputAccessoryView 向键盘添加辅助视图到使用 inputView 提供一个完全自定义的键盘,UIResponder 内部提供了以下方法来处理事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//一根或者多根手指开始触摸 view,系统会自动调用 view 的下面方法
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
//一根或者多根手指在 view 上移动,系统会自动调用 view 的下面方法(随着手指的移动,会持续调用该方法)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
//一根或者多根手指离开 view,系统会自动调用 view 的下面方法
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
//触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用 view 的下面方法
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
//加速计事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;
//远程控制事件
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;

UIControl

UIControl 建立在视图上,增加了更多的交互支持。最重要的是,它增加了 target / action 模式。看一下具体的子类,我们可以看一下按钮,日期选择器,文本框等等。创建交互控件时,你通常想要子类化一个 UIControl

一些常见的像 bar buttons 虽然也支持 target / action,和 text view 其实并不是 UIControl

UIControl 是控制对象,继承于 UIView,如传达用户意图的应用程序按钮和滑块的基类。你不能使用 UIControl 的类直接实例化控件。相反,它定义了它的所有子类的通用接口和行为结构

UIControl 主要包括触摸事件、加速事件、远程事件这几种。

UIControl 的常用属性

1
2
//控件默认是启用的。要禁用控件,可以将 enabled 属性设置为 NO,这将导致控件忽略任何触摸事件。被禁用后,控件还可以用不同的方式显示自己,比如变成灰色不可用。虽然是由控件的子类完成的,这个属性却存在于 `UIControl` 中。
BOOL enabled;
1
2
//当用户选中控件时,`UIControl` 类会将其 selected 属性设置为 YES。子类有时使用这个属性来让控件选择自身,或者来表现不同的行为方式。
BOOL selected;
1
BOOL highlighted;
1
2
3
4
5
6
//控件如何在垂直方向上布置自身的内容。
UIControlContentVerticalAlignment contentVerticalAlignment;
UIControlContentVerticalAlignmentCenter //居中
UIControlContentVerticalAlignmentTop //居顶
UIControlContentVerticalAlignmentBottom //居下
UIControlContentVerticalAlignmentFill
1
2
//控件如何在水平方向上布置自身的内容
UIControlContentHorizontalAlignment contentHorizontalAlignment;
1
2
// `UIControl`状态(只读)
UIControlState state;
1
2
//只读
BOOL tracking;
1
2
//是否touchInside(只读)
BOOL touchInside;

常用方法

1
2
3
4
5
//事件通知。UIControl类提供了一个标准机制,来进行事件登记和接收。这令你可以指定你的控件在发生特定事件时,通知代理类的一个方法。
- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event;
- (void)cancelTrackingWithEvent:(UIEvent *)event;
1
2
//注册一个事件
- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;
1
2
//移除事件通知。要删除一个或多个事件的相应动作,可以使用`UIControl`类的removeTarget方法。使用nil值就可以将给定事件目标的所有动作删除
- (void)removeTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;
1
2
//取得关于一个控件所有指定动作的列表,可以使用allTargets方法。这个方法返回一个NSSet,其中包含事件的完整列表
- (NSSet*)allTargets;
1
2
//获取关于一个控件所有事件的列表
- (UIControlEvents)allControlEvents;
1
2
//获取针对某一特定事件目标的全部动作列表
- (NSArray *)actionsForTarget:(id)target forControlEvent:(UIControlEvents)controlEvent;
1
2
3
//如果设计了一个自定义控件类,可以使用sendActionsForControlEvent方法,为基本的`UIControl`事件或自己的自定义事件发送通知。例如,如果你的控件值正在发生变化,就可以发送相应通知,通过控件的代码可以指定时间目标,这个通知将被传播到这些指定的目标。
- (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event;
- (void)sendActionsForControlEvents:(UIControlEvents)controlEvents;

UIGestureRescognizer

UIGestureRescognizer 是一类手势识别器对象,它可以附属在你指定的 View 上,并且为其设定指定的手势操作,例如是点击、滑动或者是拖拽。当触控事件发生时,设置了Gesture Recognizer`s` 的 View 会先通过识别器去拦截触控事件,如果该触控事件是事先为 View 设定的触控监听事件,那么Gesture Recognizers 将会发送动作消息给目标处理对象,目标处理对象则对这次触控事件进行处理,先看看如下流程图。

img

在 iOS 中,View 就是我们在屏幕上看到的各种 UI 控件,当一个触控事件发生时,Gesture Recognizer`s` 会先获取到指定的事件,然后发送 `action message` 给目标对象 `target`,目标对象就是 `ViewController`,在 `ViewController` 中通过事件方法完成对该事件的处理。Gesture Recognizers 能设置诸如单击、滑动、拖拽等事件,通过 Action-Target 这种设计模式,好处是能动态为 View 添加各种事件监听,而不用去实现一个 View 的子类去完成这些功能。

常用手势识别类

UIKit 框架中,系统为我们事先定义好了一些常用的手势识别器,包括点击、双指缩放、拖拽、滑动、旋转以及长按,通过这些手势识别器我们可以构造丰富的操作方式。

子类 作用
UITapGestureRecognizer 敲击
UIPinchGestureRecognizer 捏合,用于缩放
UIPanGestureRecognizer 拖拽
UISwipeGestureRecognizer 轻扫
UIRotationGestureRecognizer 旋转
UILongPressGestureRecognizer 长按

在上表中可以看到,UIKit 框架中已经提供了诸如 UITapGestureRecognizer 在内的六种手势识别器,如果你需要实现自定义的手势识别器,也可以通过继承 UIGestureRecognizer 类并重写其中的方法来完成,这里我们就不详细讨论了。

每一个 Gesture Recognizer 关联一个 View,但是一个 View 可以关联多个 Gesture Recognizer,因为一个 View可能还能响应多种触控操作方式。当一个触控事件发生时,Gesture Recognizer 接收一个动作消息要先于 View 本身,结果就是 Gesture Recognizer 作为 View 处理触控事件的代表,或者叫代理。当 Gesture Recognizer 接收到指定的事件时,它就会发送一条 action messageViewController 并处理。

UIGestureRescognizer 常用属性和方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@interface UIGestureRecognizer : NSObject
//创建一个手势对象并添加触发事件
- (instancetype)initWithTarget:(nullable id)target action:(nullable SEL)action NS_DESIGNATED_INITIALIZER;
//给一个手势对象添加监听事件
- (void)addTarget:(id)target action:(SEL)action;
//移除一个手势的监听事件
- (void)removeTarget:(nullable id)target action:(nullable SEL)action;
//设置能识别到手势的最少的轻触次数(默认为1)
@property (nonatomic) NSUInteger numberOfTapsRequired;
//设置能识别到手势的最少的手指的个数(默认为1)
@property (nonatomic) NSUInteger numberOfTouchesRequired;
//获取当前手势状态
@property(nonatomic,readonly) UIGestureRecognizerState state;
//委托
@property(nullable,nonatomic,weak) id <UIGestureRecognizerDelegate> delegate;
//手势识别是否可用
@property(nonatomic, getter=isEnabled) BOOL enabled;
//获取手势触摸的View视图 只读
@property(nullable, nonatomic,readonly) UIView *view;
/*是否取消触摸控件的响应
默认为YES,这种情况下当手势识别器识别到触摸之后,会发送touchesCancelled
给触摸到的控件以取消控件view对touch的响应,这个时候只有手势识别器响应touch,
当设置成NO时,手势识别器识别到触摸之后不会发送touchesCancelled给控件,
这个时候手势识别器和控件view均响应touch。
注意:手势识别和触摸事件是同时存在的,只是因为touchesCancelled导致触摸事件失效。*/
@property(nonatomic) BOOL cancelsTouchesInView;
/*是否延迟发送触摸事件给触摸到的控件
默认是NO,这种情况下当发生一个触摸时,手势识别器先捕捉到到触摸,
然后发给触摸到的控件,两者各自做出响应。
如果设置为YES,手势识别器在识别的过程中(注意是识别过程),不会将触摸发给触摸到的控件,即控件不会有任何触摸事件。
只有在识别失败之后才会将触摸事件发给触摸到的控件,这种情况下控件view的响应会延迟约0.15ms。*/
@property(nonatomic) BOOL delaysTouchesBegan;
//如果触摸识别失败是否立即结束本次手势识别的触摸事件
@property(nonatomic) BOOL delaysTouchesEnded;
/*指定一个手势需要另一个手势执行失败才会执行,同时触发多个手势使用其中一个手势的解决办法
有时手势是相关联的,如单机和双击,点击和长按,点下去瞬间可能只会识别到单击无法识别其他,
该方法可以指定某一个手势,即便自己已经满足条件了,也不会立刻触发,会等到该指定的手势确定失败之后才触发
*/
- (void)requireGestureRecognizerToFail:(UIGestureRecognizer *)otherGestureRecognizer;
//获取当前触摸在指定视图上的点
- (CGPoint)locationInView:(nullable UIView*)view;
//获取触摸手指数
- (NSUInteger)numberOfTouches;
//多指触摸的触摸点相对于指定视图的位置
- (CGPoint)locationOfTouch:(NSUInteger)touchIndex inView:(nullable UIView*)view;
@end
//代理方法
@protocol UIGestureRecognizerDelegate <NSObject>
@optional
//开始进行手势识别时调用的方法,返回NO则结束识别,不再触发手势,用处:可以在控件指定的位置使用手势识别
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer;
/*是否支持多手势触发,返回YES,则可以多个手势一起触发方法,返回NO则为互斥。
是否允许多个手势识别器共同识别,一个控件的手势识别后是否阻断手势识别继续向下传播。
默认返回NO,如果为YES,响应者链上层对象触发手势识别后,如果下层对象也添加了手势并成功识别也会继续执行,
否则上层对象识别后则不再继续传播*/
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;
//这个方法返回YES,第一个手势和第二个互斥时,第一个会失效
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;
//这个方法返回YES,第一个和第二个互斥时,第二个会失效
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer;
/*手指触摸屏幕后回调的方法,返回NO则不再进行手势识别,方法触发等
此方法在window对象在有触摸事件发生时,调用`Gesture Recognizer`的
touchesBegan:withEvent:方法之前调用。
如果返回NO,则`Gesture Recognizer`不会看到此触摸事件。(默认情况下为YES)*/
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch;
@end

UILongPressGestureRecognizer 常用属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@interface UILongPressGestureRecognizer : UIGestureRecognizer
//设置能识别到手势的最少的轻触次数(默认为1)
@property (nonatomic) NSUInteger numberOfTapsRequired;
//设置能识别到手势的最少的手指的个数(默认为1)
@property (nonatomic) NSUInteger numberOfTouchesRequired;
//设置能识别到长按手势的最短的长按时间,单位:秒,默认为0.5
@property (nonatomic) CFTimeInterval minimumPressDuration;
//设置长按时允许移动的最大距离,单位:像素,默认为10像素
@property (nonatomic) CGFloat allowableMovement;
@end
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressAction:)];
//设置能识别到长按手势的最小的长按时间
longPress.minimumPressDuration = 0.5;
//"容错的范围"
longPress.allowableMovement = 10;
//把长按手势添加到对应的控件中
[self.imgView addGestureRecognizer:longPress];

UISwipeGestureRecognizer 常用属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef NS_OPTIONS(NSUInteger, UISwipeGestureRecognizerDirection) {
UISwipeGestureRecognizerDirectionRight = 1 << 0, //向右滑
UISwipeGestureRecognizerDirectionLeft = 1 << 1, //向左滑
UISwipeGestureRecognizerDirectionUp = 1 << 2, //向上滑
UISwipeGestureRecognizerDirectionDown = 1 << 3 //向下滑
};
@interface UISwipeGestureRecognizer : UIGestureRecognizer`
//最少触摸手指个数,默认为1
@property(nonatomic) NSUInteger numberOfTouchesRequired;
//设置轻扫手势支持的方向,默认为向右滑
@property(nonatomic) UISwipeGestureRecognizerDirection direction;
@end

UIRotationGestureRecognizer 常用属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@interface UIRotationGestureRecognizer : UIGestureRecognizer
//旋转的角度
@property (nonatomic) CGFloat rotation;
//旋转速度,单位:度/秒、
@property (nonatomic,readonly) CGFloat velocity;
@end
//为图片框添加一个旋转手势
UIRotationGestureRecognizer *rotation = [[UIRotationGestureRecognizer alloc] initWithTarget:self action:@selector(rotateAction:)];rotation.delegate = self;
[self.imgView addGestureRecognizer:rotation];
//旋转手势的监听方法
- (void)rotateAction:(UIRotationGestureRecognizer *)recognizer {
//在原来的基础上, 累加多少度
recognizer.view.transform = CGAffineTransformRotate(recognizer.view.transform, recognizer.rotation);
//每次旋转完毕后将rotation的值, 恢复到0的位置.recognizer.rotation = 0;
}

UIPanGestureRecognizer 常用属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@interface UIPanGestureRecognizer : UIGestureRecognizer
//设置触发拖拽最少手指数,默认为1
@property (nonatomic) NSUInteger minimumNumberOfTouches;
//设置触发拖拽最多手指数,默认为 UINT_MAX 无限大
@property (nonatomic) NSUInteger maximumNumberOfTouches;
//获取当前拖拽位置
- (CGPoint)translationInView:(nullable UIView *)view;
//设置当前拖拽位置
- (void)setTranslation:(CGPoint)translation inView:(nullable UIView *)view;
//设置拖拽速度,单位:像素/秒
- (CGPoint)velocityInView:(nullable UIView *)view;
@end
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panAction:)];
[self.imgView addGestureRecognizer:pan];
//拖拽手势的监听方法
- (void)panAction:(UIPanGestureRecognizer *)recognizer {
//获取手指拖拽的时候, 平移的值
CGPoint translation = [recognizer translationInView:recognizer.view];
//让当前控件做响应的平移
recognizer.view.transform = CGAffineTransformTranslate(recognizer.view.transform, translation.x, translation.y);
//每次平移手势识别完毕后, 让平移的值不要累加
[recognizer setTranslation:CGPointZero inView:recognizer.view];
}

连续和不连续动作

触控动作同时分为连续动作和不连续动作,连续动作例如滑动和拖拽,它会持续一小段时间,而不连续动作例如单击,它瞬间就会完成,在这两类事件的处理上又稍有不同。

对于不连续动作,Gesture Recognizer 只会给 ViewContoller 发送一个单一的动作消息,而对于连续动作,Gesture Recognizer 会发送多条动作消息给 ViewContoller,直到所有的事件都结束。

为一个 View 添加 GestureRecognizer 有两种方式,一种是通过 InterfaceBuilder 实现,另一种就是通过代码实现,我们看看通过代码来如何实现。

1
2
3
4
5
6
7
8
//初始化手势
let tap = UITapGestureRecognizer(target: self, action: #selector(self.doSomeThing(tap:)))
//指定操作为点击 1 次
tap.numberOfTapsRequired = 1
//需要 2 根手指同时点击
tap.numberOfTouchesRequired = 2
//为当前 View 添加 GestureRecognizer
view.addGestureRecognizer(tap

在事件处理过程中,这两种方式所处的状态又各有不同,首先,所有的触控事件最开始都是处于可用状态 Possible,对应 UIKit 里面的 UIGestureRecognizerStatePossible 类,如果是不连续动作事件,则状态只会从 Possible 转变为已识别状态 Recognized 或者是失败状态 Failed。例如一次成功的单击动作,就对应了 Possible-Recognized 这个过程。

img

手势识别有以下几种状态:

枚举值 定义
UIGestureRecognizerStatePossible 没有触摸事件发生,所有手势识别的默认状态
UIGestureRecognizerStateBegan 一个手势已经开始但尚未改变或者完成时
UIGestureRecognizerStateChanged 手势状态改变
UIGestureRecognizerStateEnded 手势完成
UIGestureRecognizerStateCancelled 手势取消,恢复至Possible状态
UIGestureRecognizerStateFailed 手势失败,恢复至Possible状态

如果是连续动作事件,如果事件没有失败并且连续动作的第一个动作被 Recognized,则从 Possible 状态转移到 Began 状态,这里表示连续动作的开始,接着会转变为 Changed 状态,在这个状态下会不断循环的处理连续动作,直到动作执行完成变转变为 Recognized 已识别状态,最终该动作会处于完成状态 Ended,另外,连续动作事件的处理状态会从 Changed 状态转变为 Canceled 状态,原因是识别器认为当前的动作已经不匹配当初对事件的设定了。

UITouch & UIEvent

在屏幕上的每一次动作事件都是一次 Touch,在 iOS 中用 UITouch 对象表示每一次的触控,多个 Touch 组成一次 Event,用 UIEvent 来表示一次事件对象。目前 iOS 设备支持的多点操作手指数最多是 5,下图展示了一个 UIEvent 对象与多个 UITouch 对象之间的关系。

img

当用户触摸屏幕后,就会产生相应的事件,所有相关的 UITouch 对象都被包装在事件中,被程序交由特定的对象来处理。UITouch 对象直接包括触摸的详细信息,比如触摸的位置、时间、阶段。

当手指移动时,系统会更新同一个 UITouch 对象,使之能够一直保存该手指在的触摸位置,当手指离开屏幕时,系统会销毁相应的 UITouch 对象。

UITouch 的常用属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//触摸事件在屏幕上有一个周期
typedef NS_ENUM(NSInteger, UITouchPhase) {
UITouchPhaseBegan, //开始触摸
UITouchPhaseMoved, //移动
UITouchPhaseStationary, //停留
UITouchPhaseEnded, //触摸结束
UITouchPhaseCancelled, //触摸中断
};
//检测是否支持3DTouch
typedef NS_ENUM(NSInteger, UIForceTouchCapability) {
UIForceTouchCapabilityUnknown = 0, //3D Touch检测失败
UIForceTouchCapabilityUnavailable = 1, //3D Touch不可用
UIForceTouchCapabilityAvailable = 2 //3D Touch可用
};
@interface UITouch : NSObject
//触摸产生或变化的时间戳 只读
@property(nonatomic,readonly) NSTimeInterval timestamp;
//触摸周期内的各个状态
@property(nonatomic,readonly) UITouchPhase phase;
//短时间内点击的次数 只读
@property(nonatomic,readonly) NSUInteger tapCount;
//获取手指与屏幕的接触半径 IOS8以后可用 只读
@property(nonatomic,readonly) CGFloat majorRadius NS_AVAILABLE_IOS(8_0);
//获取手指与屏幕的接触半径的误差 IOS8以后可用 只读
@property(nonatomic,readonly) CGFloat majorRadiusTolerance NS_AVAILABLE_IOS(8_0);
//触摸时所在的窗口 只读
@property(nullable,nonatomic,readonly,strong) UIWindow *window;
//触摸时所在视图
@property(nullable,nonatomic,readonly,strong) UIView *view;
//获取触摸手势
@property(nullable,nonatomic,readonly,copy) NSArray <UIGestureRecognizer *> *gestureRecognizers
//取得在指定视图的位置
//返回值表示触摸在view上的位置
//这里返回的位置是针对view的坐标系的(以view的左上角为原点(0,0))
//调用时传入的view参数为nil的话,返回的是触摸点在`UIWindow`的位置
- (CGPoint)locationInView:(nullable UIView *)view;
//该方法记录了前一个触摸点的位置
- (CGPoint)previousLocationInView:(nullable UIView *)view;
//获取触摸压力值,一般的压力感应值为1.0 IOS9 只读
@property(nonatomic,readonly) CGFloat force NS_AVAILABLE_IOS(9_0);
//获取最大触摸压力值
@property(nonatomic,readonly) CGFloat maximumPossibleForce NS_AVAILABLE_IOS(9_0);
@end

当手指接触到屏幕,不管是单点触摸还是多点触摸,事件都会开始,直到用户所有的手指都离开屏幕。期间所有的 UITouch 对象都被包含在 UIEvent 事件对象中,由程序分发给处理者,事件记录了这个周期中所有触摸对象状态的变化。

只要屏幕被触摸,系统就会报若干个触摸的信息封装到 UIEvent 对象中发送给程序,由管理程序 UIApplication 对象将事件分发。一般来说,事件将被发给主窗口,然后传给第一响应者对象处理。

在上述过程中,完成了一次双指缩放的事件动作,每一次手指状态的变化都对应事件动作处理过程中得一个阶段。通过 Began-Moved-Ended 这几个阶段的 Touch 共同构成了一次事件 Event。在事件响应对象 UIResponder 中有对应的方法来分别处理这几个阶段的事件。

1
2
3
4
5
6
7
8
//一根或者多根手指开始触摸 view,系统会自动调用 view 的下面方法
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
//一根或者多根手指在 view 上移动,系统会自动调用 view 的下面方法(随着手指的移动,会持续调用该方法)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
//一根或者多根手指离开 view,系统会自动调用 view 的下面方法
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
//触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用 view 的下面方法
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

4 个触摸事件处理方法中,都有 NSSet *touchesUIEvent *event 两个参数,一次完整的触摸过程中,只会产生一个事件对象,4 个触摸方法都是同一个 event 参数。

如果两根手指同时触摸一个 View,那么 View 只会调用一次 touchesBegan:withEvent: 方法,touches 参数中装着 2 个 UITouch 对象。

如果这两根手指一前一后分开触摸同一个 View,那么 View 会分别调用 2 次 touchesBegan:withEvent: 方法,并且每次调用时的 touches 参数中只包含一个 UITouch 对象。根据 touchesUITouch 的个数可以判断出是单点触摸还是多点触摸。

UIEvent 常用属性和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//事件类型
typedef NS_ENUM(NSInteger, UIEventType) {
UIEventTypeTouches,//触控
UIEventTypeMotion, //加速感应器
UIEventTypeRemoteControl,//远程操作
UIEventTypePresses //3D touch
};
//触摸事件的类型
typedef NS_ENUM(NSInteger, UIEventSubtype) {
UIEventSubtypeNone = 0,
//摇晃
UIEventSubtypeMotionShake = 1,
//播放
UIEventSubtypeRemoteControlPlay = 100,
//暂停
UIEventSubtypeRemoteControlPause = 101,
//停止
UIEventSubtypeRemoteControlStop = 102,
//播放和暂停切换
UIEventSubtypeRemoteControlTogglePlayPause = 103,
//下一首
UIEventSubtypeRemoteControlNextTrack = 104,
//上一首
UIEventSubtypeRemoteControlPreviousTrack = 105,
//开始后退
UIEventSubtypeRemoteControlBeginSeekingBackward = 106,
//结束后退
UIEventSubtypeRemoteControlEndSeekingBackward = 107,
//开始快进
UIEventSubtypeRemoteControlBeginSeekingForward = 108,
//结束快进
UIEventSubtypeRemoteControlEndSeekingForward = 109,
};
@interface UIEvent : NSObject
//事件类型
@property(nonatomic,readonly) UIEventType
//触摸事件的类型
@property(nonatomic,readonly) UIEventSubtype
//事件的时间戳
@property(nonatomic,readonly) NSTimeInterval timestamp;
//所有的触摸
- (nullable NSSet <UITouch *> *)allTouches;
//获得`UIWindow`的触摸
- (nullable NSSet <UITouch *> *)touchesForWindow:(UIWindow *)window;
//获得`UIView`的触摸
- (nullable NSSet <UITouch *> *)touchesForView:(UIView *)view;
//获得事件中特定手势的触摸
- (nullable NSSet <UITouch *> *)touchesForGestureRecognizer:(UIGestureRecognizer *)gesture ;
//会将丢失的触摸放到一个新的 `UIEvent` 数组中,你可以用 coalescedTouchesForTouch(_:) 方法来访问
- (nullable NSArray <UITouch *> *)coalescedTouchesForTouch:(UITouch *)touch;
//辅助`UITouch`的触摸,预测发生了一系列主要的触摸事件。这些预测可能不完全匹配的触摸的真正的行为,因为它的移动,所以他们应该被解释为一个估计。
- (nullable NSArray <UITouch *> *)predictedTouchesForTouch:(UITouch *)touch;
@end

UIEvent 是代表 iOS 系统中的一个事件,一个事件包含一个或多个的 UITouchUIEvent 分为三类:

  1. UIEventTypeTouches 触摸事件,通过触摸、手势进行触发,例如手指点击、缩放;
  2. UIEventTypeMotion 运动事件,通过加速器进行触发,例如手机晃动;
  3. UIEventTypeRemoteControl 远程控制事件,通过其他远程设备触发,例如耳机控制按钮;

触摸对象的事件类型包括一个或多个触摸,触摸与某一事件联系在一起。一个触摸是被一个 UITouch 对象调用的。当一个事件触发了,系统将会把它传递给合适的响应对象并通过 UIEvent 对象发出一个消息。

调用 UIResponder 方法如 touchesBegan:withEvent:,响应对象可以分配触摸事件到合适的触摸类型并适当的控制他们。UIEvent 中的方法可以让你获取全部的触摸事件 allTouches 或者给定的视图或者窗口 touchesForView: 或者 touchesForWindow:,它可以分辨从响应对象传递过来的事件对象发生的时间 timestamp

一个 UIEvent 对象贯穿在多点触摸事件的序列中,UIKit 重用同一个 UIEvent 实例来分配每一个事件到应用程序。你不需要保持一个事件对象或者任何从事件对象返回的对象。如果你需要保存事件对象然后传递到另外一个对象,你需要从 UITouch 或者 UIEvent 中复制信息。

你可以通过类型属性和子类型属性,获取事件类型和事件的子类型。UIEvent 定义了事件的类型为触摸,摇晃和遥控事件,它也定义了摇晃事件的子类型,以及为遥控事件定义了一系列的子类型。

参考链接

iOS触摸事件的流动:http://shellhue.github.io/2017/03/04/FlowOfUITouch/

iOS事件的传递与响应:http://blog.csdn.net/yongyinmg/article/details/19616527